spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { PageHeader, Section, Note } from '@/components/ui/section';5import { EmptyState } from '@/components/ui/empty-state';6import { Freshness } from '@/components/ui/freshness';7import { Badge, ClaimBadge, StatusBadge } from '@/components/ui/badge';8import { SourceBadge } from '@/components/ui/source-badge';9import { EPI_METRIC_LABEL } from '@/lib/queries/epidemiology';10import { YEAR_MIN, yearApprovalCounts, yearApprovals, yearEpidemiology, yearLiterature, yearTopSponsors, yearTrialTotals, yearTrialsByCancer, yearTrialsByPhase, yearsWithData } from '@/lib/queries/year';11import { fmtDate, fmtInt, fmtPct, humanize, phaseLabel, truncate } from '@/lib/format';1213export const revalidate = 3600;1415type Params = { year: string };1617function parseYear(raw: string): number | null {18 if (!/^\d{4}$/.test(raw)) return null;19 const y = Number(raw);20 const max = new Date().getUTCFullYear();21 return y >= YEAR_MIN && y <= max ? y : null;22}2324export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {25 const y = parseYear((await params).year);26 return y ? { title: `${y} in cancer`, description: `Oncology approvals, registered studies, research output and registry data of ${y}, from dated records in the index.`, alternates: { canonical: `/year/${y}` } } : { title: 'Year' };27}2829export default async function YearPage({ params }: { params: Promise<Params> }) {30 const year = parseYear((await params).year);31 if (year == null) notFound();32 const [years, approvalCounts, approvals, phases, totals, byCancer, sponsors, lit, epi] = await Promise.all([yearsWithData(), yearApprovalCounts(year), yearApprovals(year, 60), yearTrialsByPhase(year), yearTrialTotals(year), yearTrialsByCancer(year, 12), yearTopSponsors(year, 10), yearLiterature(year, 15), yearEpidemiology(year)]);33 const current = new Date().getUTCFullYear();34 const partial = year === current;35 const prev = years.find((y) => y < year);36 const next = [...years].reverse().find((y) => y > year);37 const totalApprovals = approvalCounts.reduce((s, r) => s + r.n, 0);3839 return (40 <div className="pb-8">41 <PageHeader kicker="Year in cancer" title={`${year} in cancer`} lede={`What the index holds for ${year}: regulatory decisions dated that year, studies first posted that year, PubMed output per cancer for that year and the registry observations whose reference year it is. Every figure is a count of dated source records — nothing is estimated${partial ? '; the current year is incomplete by construction' : ''}.`}>42 <nav aria-label="Years" className="mt-3 flex flex-wrap items-center gap-2 text-[13px]">43 {prev ? (44 <Link href={`/year/${prev}`} className="ci-chip">45 ← {prev}46 </Link>47 ) : null}48 {next ? (49 <Link href={`/year/${next}`} className="ci-chip">50 {next} →51 </Link>52 ) : null}53 <span className="text-ink-3">Years with data: {years.length ? `${years[years.length - 1]}–${years[0]}` : 'none'}</span>54 <Link href="/pulse" className="ci-link">55 Last 30 days →56 </Link>57 </nav>58 </PageHeader>5960 {partial ? <Note tone="warn">{year} is still in progress: counts grow with each connector run and registries have not yet published {year} observations.</Note> : null}6162 <div className="mt-6 grid gap-10 lg:grid-cols-[1.35fr_1fr]">63 <div className="space-y-10">64 <Section id="approvals" kicker="Regulatory" title={`Approval records dated ${year} (${fmtInt(totalApprovals)})`} description="Approved, accelerated or conditional records by authority; original applications and records naming one cancer are listed first. One record is one decision for one application or product identifier.">65 {approvalCounts.length ? (66 <>67 <p className="mb-2 flex flex-wrap gap-1.5 text-[12px]">68 {approvalCounts.map((r) => (69 <Badge key={`${r.authority}-${r.jurisdiction}`} tone="outline" title={`${r.drugs} distinct drugs · ${r.with_cancer} records naming one cancer`}>70 {r.authority} · {r.jurisdiction}: {fmtInt(r.n)} records / {fmtInt(r.drugs)} drugs71 </Badge>72 ))}73 </p>74 <ul className="divide-y divide-rule text-[13.5px]">75 {approvals.map((a) => (76 <li key={a.id} className="grid gap-x-3 py-2 sm:grid-cols-[92px_1fr]">77 <span className="ci-mono text-[12px] text-ink-3">{fmtDate(a.approval_date)}</span>78 <span className="min-w-0">79 <Link href={`/drug/${a.drug_slug}`} className="ci-link font-medium">80 {a.drug_name}81 </Link>{' '}82 <Badge tone="outline">83 {a.authority} · {a.jurisdiction}84 </Badge>{' '}85 {a.approval_type ? <Badge tone={a.approval_type === 'ORIG' ? 'accent' : 'neutral'}>{a.approval_type === 'ORIG' ? 'original application' : a.approval_type === 'SUPPL' ? 'supplement' : a.approval_type}</Badge> : null}{' '}86 {a.accelerated ? <Badge tone="warn">accelerated</Badge> : null}{' '}87 <StatusBadge status={a.status} />{' '}88 {a.cancer_slug ? (89 <Link href={`/cancer/${a.cancer_slug}`} className="ci-link">90 {a.cancer_name}91 </Link>92 ) : a.tumor_agnostic ? (93 <Badge tone="accent">tumor-agnostic</Badge>94 ) : (95 <span className="text-ink-3">cancer not stated in this record</span>96 )}97 <span className="mt-0.5 block text-[12.5px] text-ink-2" title={a.indication}>98 {truncate(a.indication, 160)}99 </span>100 <SourceBadge p={{ sourceSlug: a.source_slug }} compact />101 </span>102 </li>103 ))}104 </ul>105 <p className="mt-2 flex items-center gap-2 text-[12px]">106 <ClaimBadge kind="regulatory" />107 <Link href={`/approvals?from=${year}&to=${year}`} className="ci-link">108 All {year} records with filters →109 </Link>110 </p>111 </>112 ) : (113 <EmptyState compact title={`No approval record dated ${year}`}>Approval records exist from the authorities ingested (FDA via openFDA, Health Canada, EMA); older decisions may be undated in the source.</EmptyState>114 )}115 </Section>116117 <Section id="trials" kicker="Clinical research" title={`Studies first posted in ${year}`} description="ClinicalTrials.gov oncology studies whose first-posted date falls in the year (all study types unless stated). Cancer attribution uses reconciled conditions over descendants; one study can count for several top-level cancers.">118 {totals ? (119 <>120 <dl className="mb-3 grid grid-cols-2 gap-x-6 gap-y-2 text-[13.5px] sm:grid-cols-3 lg:grid-cols-6">121 {[122 ['Studies', totals.total],123 ['Interventional', totals.interventional],124 ['Phase III', totals.phase3],125 ['Industry-led', totals.industry],126 ['With posted results', totals.with_results],127 ['Countries with sites', totals.countries],128 ].map(([k, v]) => (129 <div key={String(k)}>130 <dt className="ci-kicker">{k}</dt>131 <dd className="ci-num text-left font-display text-2xl">{fmtInt(v as number)}</dd>132 </div>133 ))}134 </dl>135 <div className="grid gap-6 lg:grid-cols-2">136 <div className="ci-table-wrap">137 <table className="ci-table">138 <caption className="pb-1 text-[12.5px] text-ink-3">By phase (a study with two phases counts in both)</caption>139 <thead>140 <tr>141 <th>Phase</th>142 <th className="num">Studies</th>143 <th className="num">Interventional</th>144 <th className="num">Industry share</th>145 </tr>146 </thead>147 <tbody>148 {phases.map((p) => (149 <tr key={p.phase}>150 <td>{p.phase === 'NA' ? 'Not applicable / not stated' : phaseLabel(p.phase)}</td>151 <td className="num">{fmtInt(p.n)}</td>152 <td className="num">{fmtInt(p.interventional)}</td>153 <td className="num text-ink-3">{fmtPct(p.n ? p.industry / p.n : 0, 0)}</td>154 </tr>155 ))}156 </tbody>157 </table>158 </div>159 <div className="ci-table-wrap">160 <table className="ci-table">161 <caption className="pb-1 text-[12.5px] text-ink-3">Top-level cancers by studies (descendants included)</caption>162 <thead>163 <tr>164 <th>Cancer</th>165 <th className="num">Studies</th>166 <th className="num">Phase III</th>167 </tr>168 </thead>169 <tbody>170 {byCancer.map((c) => (171 <tr key={c.id}>172 <td>173 <Link href={`/cancer/${c.slug}/trials`} className="ci-link">174 {c.canonical_name}175 </Link>176 </td>177 <td className="num">{fmtInt(c.trials)}</td>178 <td className="num">{fmtInt(c.phase3)}</td>179 </tr>180 ))}181 </tbody>182 </table>183 </div>184 </div>185 <div className="mt-4 ci-table-wrap">186 <table className="ci-table">187 <caption className="pb-1 text-[12.5px] text-ink-3">Lead sponsors of interventional studies first posted in {year}</caption>188 <thead>189 <tr>190 <th>Lead sponsor</th>191 <th>Class</th>192 <th className="num">Studies</th>193 <th className="num">Phase III</th>194 </tr>195 </thead>196 <tbody>197 {sponsors.map((s) => (198 <tr key={s.lead_sponsor}>199 <td>200 <Link href={`/trials?q=${encodeURIComponent(s.lead_sponsor)}`} className="ci-link">201 {s.lead_sponsor}202 </Link>203 </td>204 <td className="text-[12.5px] text-ink-3">{s.lead_sponsor_class ? humanize(s.lead_sponsor_class) : '—'}</td>205 <td className="num">{fmtInt(s.n)}</td>206 <td className="num">{fmtInt(s.phase3)}</td>207 </tr>208 ))}209 </tbody>210 </table>211 </div>212 <p className="mt-2 flex items-center gap-2 text-[12px]">213 <ClaimBadge kind="published" /> <span className="text-ink-3">source: clinicaltrials · first_posted_date as posted</span>214 </p>215 </>216 ) : (217 <EmptyState compact title={`No study first posted in ${year} in the index`}>The ClinicalTrials.gov connector ingests oncology studies of every year; first-posted dates start in 1999 for most registrations.</EmptyState>218 )}219 </Section>220 </div>221222 <aside className="space-y-10">223 <Section id="literature" kicker="Research output" title={`PubMed records, ${year}`} description="Records matching each top-level cancer's stored PubMed query with a publication year equal to this year, with the previous year for comparison. A count of indexed records, not of scientific impact.">224 {lit.length ? (225 <>226 <div className="ci-table-wrap">227 <table className="ci-table">228 <thead>229 <tr>230 <th>Cancer</th>231 <th className="num">{year}</th>232 <th className="num">{year - 1}</th>233 <th className="num">Change</th>234 </tr>235 </thead>236 <tbody>237 {lit.map((l) => {238 const d = l.prev_count ? ((l.count - l.prev_count) / l.prev_count) * 100 : null;239 return (240 <tr key={l.id}>241 <td>242 <Link href={`/cancer/${l.slug}/research`} className="ci-link" title={l.query}>243 {l.canonical_name}244 </Link>245 </td>246 <td className="num">{fmtInt(l.count)}</td>247 <td className="num text-ink-3">{l.prev_count == null ? '—' : fmtInt(l.prev_count)}</td>248 <td className={`num ${d == null ? 'text-ink-4' : d > 0 ? 'text-ok' : d < 0 ? 'text-danger' : 'text-ink-3'}`}>{d == null ? '—' : `${d > 0 ? '+' : ''}${d.toFixed(0)} %`}</td>249 </tr>250 );251 })}252 </tbody>253 </table>254 </div>255 <p className="mt-2 flex items-center gap-2 text-[12px]">256 <ClaimBadge kind="computed" /> <span className="text-ink-3">literature_counts · window y{year} · query stored per row (hover a cancer)</span>257 </p>258 <Freshness dataUpdatedAt={lit[0]?.computed_at ?? null} extra="source: pubmed" />259 </>260 ) : (261 <EmptyState compact title={`No literature window for ${year}`}>Yearly PubMed windows are computed from 2016 onwards for entities with a stored query.</EmptyState>262 )}263 </Section>264265 <Section id="epidemiology" kicker="Registries" title={`Observations with reference year ${year}`} description="What registries have published for this year: observations by source, metric and geography. Registries publish with a lag of one to three years.">266 {epi.length ? (267 <div className="ci-table-wrap">268 <table className="ci-table">269 <thead>270 <tr>271 <th>Geography</th>272 <th>Metric</th>273 <th>Source</th>274 <th className="num">Observations</th>275 <th className="num">Cancers</th>276 </tr>277 </thead>278 <tbody>279 {epi.map((e) => (280 <tr key={`${e.geography_slug}-${e.metric}-${e.source_slug}`}>281 <td>282 <Link href={`/country/${e.geography_slug}?year=${year}`} className="ci-link">283 {e.geography_name}284 </Link>285 </td>286 <td className="text-[12.5px]">{EPI_METRIC_LABEL[e.metric] ?? humanize(e.metric)}</td>287 <td>288 <SourceBadge p={{ sourceSlug: e.source_slug, sourceName: e.source_name }} compact />289 </td>290 <td className="num">{fmtInt(e.n)}</td>291 <td className="num">{fmtInt(e.cancers)}</td>292 </tr>293 ))}294 </tbody>295 </table>296 </div>297 ) : (298 <EmptyState compact title={`No registry observation for ${year} yet`}>Registries publish with a lag; explore available years in the Data explorer.</EmptyState>299 )}300 <p className="mt-2 text-[12px]">301 <Link href={`/explore?from=${year}&to=${year}`} className="ci-link">302 Open {year} in the Data explorer →303 </Link>304 </p>305 </Section>306 </aside>307 </div>308 </div>309 );310}311